Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ


Displaying Raw β€’ Download

core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt 2d20cd8a4708e2ef66e98d5259f3a97ec93f240a (2d20cd8a) Text, 42.79 KB

T8b949e/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
Tf0883e@fileTb4b4b4:Te6edf3SuppressTb4b4b4(Ta5d6ff"Ta5d6ffTooManyFunctionsTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffTooGenericExceptionCaughtTa5d6ff"Tb4b4b4)

Tff7b72package T7ee787org.meshtastic.core.network.radio

Tff7b72import T7ee787co.touchlab.kermit.Logger
Tff7b72import T7ee787kotlinx.atomicfu.atomic
Tff7b72import T7ee787kotlinx.coroutines.CancellationException
Tff7b72import T7ee787kotlinx.coroutines.CoroutineExceptionHandler
Tff7b72import T7ee787kotlinx.coroutines.CoroutineScope
Tff7b72import T7ee787kotlinx.coroutines.Job
Tff7b72import T7ee787kotlinx.coroutines.NonCancellable
Tff7b72import T7ee787kotlinx.coroutines.SupervisorJob
Tff7b72import T7ee787kotlinx.coroutines.cancel
Tff7b72import T7ee787kotlinx.coroutines.delay
Tff7b72import T7ee787kotlinx.coroutines.flow.catch
Tff7b72import T7ee787kotlinx.coroutines.flow.filterIsInstance
Tff7b72import T7ee787kotlinx.coroutines.flow.first
Tff7b72import T7ee787kotlinx.coroutines.flow.launchIn
Tff7b72import T7ee787kotlinx.coroutines.flow.onEach
Tff7b72import T7ee787kotlinx.coroutines.job
Tff7b72import T7ee787kotlinx.coroutines.launch
Tff7b72import T7ee787kotlinx.coroutines.sync.Mutex
Tff7b72import T7ee787kotlinx.coroutines.sync.withLock
Tff7b72import T7ee787kotlinx.coroutines.withContext
Tff7b72import T7ee787kotlinx.coroutines.withTimeoutOrNull
Tff7b72import T7ee787org.meshtastic.core.ble.BleConnection
Tff7b72import T7ee787org.meshtastic.core.ble.BleConnectionFactory
Tff7b72import T7ee787org.meshtastic.core.ble.BleConnectionState
Tff7b72import T7ee787org.meshtastic.core.ble.BleDevice
Tff7b72import T7ee787org.meshtastic.core.ble.BleScanner
Tff7b72import T7ee787org.meshtastic.core.ble.BleWriteType
Tff7b72import T7ee787org.meshtastic.core.ble.BluetoothRepository
Tff7b72import T7ee787org.meshtastic.core.ble.DisconnectReason
Tff7b72import T7ee787org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
Tff7b72import T7ee787org.meshtastic.core.ble.MeshtasticRadioProfile
Tff7b72import T7ee787org.meshtastic.core.ble.classifyBleException
Tff7b72import T7ee787org.meshtastic.core.ble.retryBleOperation
Tff7b72import T7ee787org.meshtastic.core.ble.toMeshtasticRadioProfile
Tff7b72import T7ee787org.meshtastic.core.common.util.nowMillis
Tff7b72import T7ee787org.meshtastic.core.model.RadioNotConnectedException
Tff7b72import T7ee787org.meshtastic.core.model.util.anonymize
Tff7b72import T7ee787org.meshtastic.core.network.transport.HeartbeatSender
Tff7b72import T7ee787org.meshtastic.core.repository.RadioInterfaceService
Tff7b72import T7ee787org.meshtastic.core.repository.RadioTransport
Tff7b72import T7ee787org.meshtastic.core.repository.RadioTransportCallback
Tff7b72import T7ee787kotlin.concurrent.Volatile
Tff7b72import T7ee787kotlin.time.Duration
Tff7b72import T7ee787kotlin.time.Duration.Companion.milliseconds
Tff7b72import T7ee787kotlin.time.Duration.Companion.seconds

Tff7b72private Tff7b72const Tff7b72val Te6edf3SCAN_RETRY_COUNT Tff7b72= T79c0ff3
Tff7b72private Tff7b72val Te6edf3SCAN_RETRY_DELAY Tff7b72= T79c0ff1.Te6edf3seconds
Tff7b72private Tff7b72val Te6edf3CONNECTION_TIMEOUT Tff7b72= T79c0ff1T79c0ff5.Te6edf3seconds

T8b949e/**
* Delay after writing a heartbeat before re-polling FROMRADIO.
*
* The ESP32 firmware processes TORADIO writes asynchronously (NimBLE callback β†’ FreeRTOS main task queue β†’
* `handleToRadio()` β†’ `heartbeatReceived = true`). The immediate drain trigger in
* [KableMeshtasticRadioProfile.sendToRadio] fires before this completes, so the `queueStatus` response is not yet
* available. 200 ms is well above observed ESP32 task scheduling latency (~10–50 ms) while remaining imperceptible to
* the user.
*/
Tff7b72private Tff7b72val Te6edf3HEARTBEAT_DRAIN_DELAY Tff7b72= T79c0ff2T79c0ff0T79c0ff0.Te6edf3milliseconds

T8b949e/**
* Bounded scan duration used by both discovery paths in [findDevice]:
* - Bonded devices get one address-filtered scan before falling back to the bonded handle.
* - Non-bonded retries each use this duration.
*
* Keeping the bonded path to one scanner registration is important on Android, which throttles applications that start
* BLE scans too frequently. A single 5s window still covers multiple advertising intervals for typical power-save slots
* (~1–2s each), resolves immediately when the target advertises, and avoids consuming two scan starts per reconnect. If
* the scan misses, [findDevice] falls back to the bonded handle and [attemptConnection] keeps that patient
* `autoConnect` path bounded through [CONNECTION_TIMEOUT].
*/
Tff7b72internal Tff7b72val Te6edf3SCAN_TIMEOUT Tff7b72= T79c0ff5.Te6edf3seconds
Tff7b72private Tff7b72val Te6edf3GATT_CLEANUP_TIMEOUT Tff7b72= T79c0ff5.Te6edf3seconds

T8b949e/**
* Bounded wait for the connectionState StateFlow to reflect Connected after connectAndAwait returns.
*
* In normal operation the observer coroutine lags by milliseconds. If a fatal session failure fires before Connected is
* observed and the StateFlow was already at a stale Disconnected value (no re-emit), this timeout prevents
* [attemptConnection] from hanging indefinitely.
*/
Tff7b72private Tff7b72val Te6edf3CONNECTED_GATE_TIMEOUT Tff7b72= T79c0ff5.Te6edf3seconds

T8b949e/**
* Bounded wait for FROMNUM CCCD subscription before proceeding with the handshake.
*
* In normal operation the observe callback fires within milliseconds. If a fatal fromRadio failure fires before
* subscriptionReady completes, this timeout prevents a permanent hang.
*/
Tff7b72private Tff7b72val Te6edf3SUBSCRIPTION_READY_TIMEOUT Tff7b72= T79c0ff5.Te6edf3seconds

T8b949e/**
* Delay after onConnect before downgrading BLE connection priority to Balanced.
*
* The initial config drain (fromRadio burst) typically completes within 2–5 seconds on most devices, but slower radios
* (ESP32 with large node databases, many channels, or dense position history) can take significantly longer. 30 seconds
* provides generous margin while still ensuring we don't sustain the 7.5 ms connection interval indefinitely, which
* significantly increases battery draw on both the phone and the radio.
*/
Tff7b72private Tff7b72val Te6edf3PRIORITY_DOWNGRADE_DELAY Tff7b72= T79c0ff3T79c0ff0.Te6edf3seconds

T8b949e/**
* Settle delay after disconnecting to let the BLE stack release GATT resources before reconnecting post cache
* invalidation.
*/
Tff7b72private Tff7b72val Te6edf3POST_INVALIDATION_RECONNECT_DELAY Tff7b72= T79c0ff5T79c0ff0T79c0ff0.Te6edf3milliseconds

T8b949e/**
* A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable).
*
* This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including:
* - Bonding and discovery.
* - Automatic reconnection logic.
* - MTU and connection parameter monitoring.
* - Routing raw byte packets between the radio and [RadioTransportCallback].
*
* @param scope The coroutine scope to use for launching coroutines.
* @param scanner The BLE scanner.
* @param bluetoothRepository The Bluetooth repository.
* @param connectionFactory The BLE connection factory.
* @param callback The [RadioTransportCallback] to use for handling radio events.
* @param address The BLE address of the device to connect to.
*/
Tff7b72class T56d364BleRadioTransportTb4b4b4(
Tff7b72private Tff7b72val Te6edf3scopeTb4b4b4: Te6edf3CoroutineScopeTb4b4b4,
Tff7b72private Tff7b72val Te6edf3scannerTb4b4b4: Te6edf3BleScannerTb4b4b4,
Tff7b72private Tff7b72val Te6edf3bluetoothRepositoryTb4b4b4: Te6edf3BluetoothRepositoryTb4b4b4,
Tff7b72private Tff7b72val Te6edf3connectionFactoryTb4b4b4: Te6edf3BleConnectionFactoryTb4b4b4,
Tff7b72private Tff7b72val Te6edf3callbackTb4b4b4: Te6edf3RadioTransportCallbackTb4b4b4,
Tff7b72internal Tff7b72val Te6edf3addressTb4b4b4: Tffa657StringTb4b4b4,
Tb4b4b4) Tb4b4b4: Te6edf3RadioTransport Tb4b4b4{

T8b949e// Detached cleanup scope for last-ditch GATT teardown from the exception handler.
T8b949e// Must NOT be a child of `scope`: when an uncaught exception fires in connectionScope,
T8b949e// upper layers often tear down `scope` immediately. Launching cleanup on `scope` then
T8b949e// races the cancellation and may never start, leaking BluetoothGatt (status 133 on
T8b949e// the next reconnect). This scope is cancelled in close() once our own disconnect
T8b949e// has completed and the safety net is no longer needed.
Tff7b72private Tff7b72val Te6edf3cleanupScopeTb4b4b4: Te6edf3CoroutineScope Tff7b72= Te6edf3CoroutineScopeTb4b4b4(Te6edf3SupervisorJobTb4b4b4(Tb4b4b4) Tff7b72+ Te6edf3scopeTb4b4b4.Te6edf3coroutineContextTb4b4b4.Te6edf3minusKeyTb4b4b4(Te6edf3JobTb4b4b4)Tb4b4b4)

Tff7b72private Tff7b72val Te6edf3exceptionHandler Tff7b72= Te6edf3CoroutineExceptionHandler Tb4b4b4{ Te6edf3_Tb4b4b4, Te6edf3throwable Tff7b72-Tff7b72>
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3throwableTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Uncaught exception in connectionScopeTa5d6ff" Tb4b4b4}
Tff7b72if Tb4b4b4(Te6edf3throwable Tff7b72!is Te6edf3CancellationExceptionTb4b4b4) Tb4b4b4{
T8b949e// Record the cause BEFORE the CAS so there is no window where another coroutine
T8b949e// sees sessionFailed == true but sessionFailureCause == null. first-cause is
T8b949e// preserved: a concurrent loser only overwrites if still null.
Te6edf3recordSessionFailureCauseTb4b4b4(Te6edf3throwableTb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3sessionFailedTb4b4b4.Te6edf3compareAndSetTb4b4b4(Te6edf3expect Tff7b72= Tff7b72falseTb4b4b4, Te6edf3update Tff7b72= Tff7b72trueTb4b4b4)Tb4b4b4) Tb4b4b4{
Te6edf3radioService Tff7b72= Tff7b72null
Te6edf3isFullyConnected Tff7b72= Tff7b72false
Tff7b72val Tb4b4b4(Te6edf3isPermanentTb4b4b4, Te6edf3msgTb4b4b4) Tff7b72= Te6edf3throwableTb4b4b4.Te6edf3toDisconnectReasonTb4b4b4(Tb4b4b4)
Te6edf3callbackTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanentTb4b4b4, Te6edf3errorMessage Tff7b72= Te6edf3msgTb4b4b4)
Tb4b4b4}
Tb4b4b4}
Te6edf3cleanupScopeTb4b4b4.Te6edf3launch Tb4b4b4{
Tff7b72try Tb4b4b4{
Te6edf3bleConnectionTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Failed to disconnect in exception handlerTa5d6ff" Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72val Te6edf3connectionScopeTb4b4b4: Te6edf3CoroutineScope Tff7b72=
Te6edf3CoroutineScopeTb4b4b4(Te6edf3scopeTb4b4b4.Te6edf3coroutineContext Tff7b72+ Te6edf3SupervisorJobTb4b4b4(Te6edf3scopeTb4b4b4.Te6edf3coroutineContextTb4b4b4.Te6edf3jobTb4b4b4) Tff7b72+ Te6edf3exceptionHandlerTb4b4b4)
Tff7b72private Tff7b72val Te6edf3bleConnectionTb4b4b4: Te6edf3BleConnection Tff7b72= Te6edf3connectionFactoryTb4b4b4.Te6edf3createTb4b4b4(Te6edf3connectionScopeTb4b4b4, Te6edf3addressTb4b4b4)
Tff7b72private Tff7b72val Te6edf3writeMutexTb4b4b4: Te6edf3Mutex Tff7b72= Te6edf3MutexTb4b4b4(Tb4b4b4)

Tf0883e@Volatile Tff7b72private Tff7b72var Te6edf3connectionStartTimeTb4b4b4: Tffa657Long Tff7b72= T79c0ff0

Tff7b72private Tff7b72val Te6edf3packetsReceived Tff7b72= Te6edf3atomicTb4b4b4(T79c0ff0Tb4b4b4)

Tff7b72private Tff7b72val Te6edf3packetsSent Tff7b72= Te6edf3atomicTb4b4b4(T79c0ff0Tb4b4b4)

Tff7b72private Tff7b72val Te6edf3bytesReceived Tff7b72= Te6edf3atomicTb4b4b4(T79c0ff0LTb4b4b4)

Tff7b72private Tff7b72val Te6edf3bytesSent Tff7b72= Te6edf3atomicTb4b4b4(T79c0ff0LTb4b4b4)

Tf0883e@Volatile Tff7b72private Tff7b72var Te6edf3isFullyConnected Tff7b72= Tff7b72false
Tff7b72private Tff7b72var Te6edf3connectionJobTb4b4b4: Te6edf3Job? Tff7b72= Tff7b72null

T8b949e// Guards against duplicate callbacks when multiple writes or a write + fromRadio failure
T8b949e// fire against the same stale session. Reset at the start of each attemptConnection().
T8b949e// Write-path failures are serialized by writeMutex; fromRadio/logRadio .catch handlers run
T8b949e// on the flow collector coroutine and may race. Atomic CAS guarantees first-writer-wins β€”
T8b949e// only the caller that wins the compareAndSet(false, true) fires onDisconnect.
Tff7b72private Tff7b72val Te6edf3sessionFailed Tff7b72= Te6edf3atomicTb4b4b4(Tff7b72falseTb4b4b4)

T8b949e// Captures the exception that caused handleFailure() to tear down the session, so
T8b949e// attemptConnection() can distinguish an internal-failure disconnect from a genuine
T8b949e// LocalDisconnect (user-initiated or clean close). When non-null, the reconnect policy
T8b949e// treats the disconnect as unintentional and applies backoff escalation.
Tf0883e@Volatile Tff7b72private Tff7b72var Te6edf3sessionFailureCauseTb4b4b4: Te6edf3Throwable? Tff7b72= Tff7b72null

T8b949e// Never give up while the user has this device selected. Higher layers (SharedRadioInterfaceService)
T8b949e// own the explicit-disconnect lifecycle and will close() us when the user picks a different device or
T8b949e// toggles the connection off; until then, retry forever with the policy's exponential-backoff cap (60 s).
Tff7b72private Tff7b72val Te6edf3reconnectPolicy Tff7b72= Te6edf3BleReconnectPolicyTb4b4b4(Te6edf3maxFailures Tff7b72= Tffa657IntTb4b4b4.Te6edf3MAX_VALUETb4b4b4)

Tff7b72private Tff7b72val Te6edf3heartbeatSender Tff7b72=
Te6edf3HeartbeatSenderTb4b4b4(
Te6edf3sendToRadio Tff7b72= Tff7b72::Te6edf3handleSendToRadioTb4b4b4,
Te6edf3afterHeartbeat Tff7b72= Tb4b4b4{
Te6edf3delayTb4b4b4(Te6edf3HEARTBEAT_DRAIN_DELAYTb4b4b4)
Te6edf3radioServiceTff7b72?.Te6edf3requestDrainTb4b4b4(Tb4b4b4)
Tb4b4b4}Tb4b4b4,
Te6edf3logTag Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)

Tff7b72override Tff7b72fun Td2a8ffstartTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3connectTb4b4b4(Tb4b4b4)
Tb4b4b4}

T8b949e// --- Connection & Discovery Logic ---

T8b949e/** Robustly finds the device. Checks bonded devices, preferring a fresh scan result when available. */
Tf0883e@SuppressTb4b4b4(Ta5d6ff"Ta5d6ffReturnCountTa5d6ff"Tb4b4b4)
Tff7b72private Tff7b72suspend Tff7b72fun Td2a8fffindDeviceTb4b4b4(Tb4b4b4)Tb4b4b4: Te6edf3BleDevice Tb4b4b4{
Tff7b72val Te6edf3bondedDevice Tff7b72=
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3stateTb4b4b4.Te6edf3valueTb4b4b4.Te6edf3bondedDevicesTb4b4b4.Te6edf3firstOrNull Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3addressTb4b4b4.Te6edf3equalsTb4b4b4(Te6edf3addressTb4b4b4, Te6edf3ignoreCase Tff7b72= Tff7b72trueTb4b4b4) Tb4b4b4}

Tff7b72if Tb4b4b4(Te6edf3bondedDevice Tff7b72!Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
T8b949e// Use one bounded, address-filtered scan. Splitting this into a short scan plus an escalated scan consumed
T8b949e// two Android scanner registrations per reconnect and could hit SCAN_FAILED_SCANNING_TOO_FREQUENTLY when a
T8b949e// user switched devices while the reconnect policy and the Connections screen were also scanning.
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700${Te6edf3addressTb4b4b4.Te6edf3anonymizeTb4b4b4(Tb4b4b4)Tffd700}Ta5d6ff] Bonded device found; scanning once for a fresh advertisementTa5d6ff" Tb4b4b4}
Te6edf3scanForFreshDeviceTb4b4b4(Te6edf3SCAN_TIMEOUTTb4b4b4)Tff7b72?.Te6edf3let Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700${Te6edf3addressTb4b4b4.Te6edf3anonymizeTb4b4b4(Tb4b4b4)Tffd700}Ta5d6ff] Fresh advertisement found; using scanned deviceTa5d6ff" Tb4b4b4}
Tff7b72return Tffa657it
Tb4b4b4}

T8b949e// If the scan misses, fall back to the bonded handle. Bonded-only devices have no fresh advertisement, so
T8b949e// Kable uses autoConnect=true and Android can patiently wait for the device to advertise again.
T8b949e// This remains bounded by CONNECTION_TIMEOUT in connectAndAwait(), after which BleReconnectPolicy owns
T8b949e// retry/backoff.
Te6edf3LoggerTb4b4b4.Te6edf3w Tb4b4b4{
Ta5d6ff"Ta5d6ff[Tffd700${Te6edf3addressTb4b4b4.Te6edf3anonymizeTb4b4b4(Tb4b4b4)Tffd700}Ta5d6ff] No fresh advertisement within Tffd700$Te6edf3SCAN_TIMEOUTTa5d6ff; Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6fffalling back to bonded handle for bounded autoConnectTa5d6ff"
Tb4b4b4}
Tff7b72return Te6edf3bondedDevice
Tb4b4b4}

T8b949e// Non-bonded path: preserve existing retry behavior (SCAN_RETRY_COUNT attempts at SCAN_TIMEOUT).
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700${Te6edf3addressTb4b4b4.Te6edf3anonymizeTb4b4b4(Tb4b4b4)Tffd700}Ta5d6ff] Device not found in bonded list, scanningTa5d6ff" Tb4b4b4}
Te6edf3repeatTb4b4b4(Te6edf3SCAN_RETRY_COUNTTb4b4b4) Tb4b4b4{ Te6edf3attempt Tff7b72-Tff7b72>
Te6edf3scanForFreshDeviceTb4b4b4(Te6edf3SCAN_TIMEOUTTb4b4b4)Tff7b72?.Te6edf3let Tb4b4b4{
Tff7b72return Tffa657it
Tb4b4b4}
Tff7b72if Tb4b4b4(Te6edf3attempt Tff7b72< Te6edf3SCAN_RETRY_COUNT Tff7b72- T79c0ff1Tb4b4b4) Tb4b4b4{
Te6edf3delayTb4b4b4(Te6edf3SCAN_RETRY_DELAYTb4b4b4)
Tb4b4b4}
Tb4b4b4}
Tff7b72throw Te6edf3RadioNotConnectedExceptionTb4b4b4(Ta5d6ff"Ta5d6ffDevice not found at address Tffd700$Te6edf3addressTa5d6ff"Tb4b4b4)
Tb4b4b4}

T8b949e/**
* Performs a single BLE scan attempt for the selected [address] and returns the first matching [BleDevice], or null
* if the scan times out or fails.
*
* One scan attempt only β€” no retry, no backoff. Both bonded and non-bonded paths in [findDevice] share this
* primitive so retry policy stays centralized:
* - Bonded: one address-filtered [SCAN_TIMEOUT] attempt before [findDevice] returns the bonded handle.
* - Non-bonded: [SCAN_RETRY_COUNT] attempts at [SCAN_TIMEOUT] with [SCAN_RETRY_DELAY] between attempts.
*
* The outer [withTimeoutOrNull] is binding: the scanner receives [timeout] as a hint, but this coroutine resumes on
* its own schedule regardless of when (or whether) the scanner honors it.
*
* [CancellationException] is rethrown β€” coroutine cancellation must never be swallowed.
*/
Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffscanForFreshDeviceTb4b4b4(Te6edf3timeoutTb4b4b4: Te6edf3DurationTb4b4b4)Tb4b4b4: Te6edf3BleDevice? Tff7b72= Tff7b72try Tb4b4b4{
Te6edf3withTimeoutOrNullTb4b4b4(Te6edf3timeoutTb4b4b4) Tb4b4b4{
T8b949e// Pass both service UUID and address so the scanner can apply the most efficient platform filter.
T8b949e// Android uses address (OS-level HW filter), while CoreBluetooth (macOS) needs the service UUID because
T8b949e// it caches peripheral identifiers and may not re-report by address alone.
Te6edf3scannerTb4b4b4.Te6edf3scanTb4b4b4(Te6edf3timeout Tff7b72= Te6edf3timeoutTb4b4b4, Te6edf3serviceUuid Tff7b72= Te6edf3SERVICE_UUIDTb4b4b4, Te6edf3address Tff7b72= Te6edf3addressTb4b4b4)Tb4b4b4.Te6edf3first Tb4b4b4{
Tffa657itTb4b4b4.Te6edf3addressTb4b4b4.Te6edf3equalsTb4b4b4(Te6edf3addressTb4b4b4, Te6edf3ignoreCase Tff7b72= Tff7b72trueTb4b4b4)
Tb4b4b4}
Tb4b4b4}
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3CancellationExceptionTb4b4b4) Tb4b4b4{
Tff7b72throw Te6edf3e
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3vTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700${Te6edf3addressTb4b4b4.Te6edf3anonymizeTb4b4b4(Tb4b4b4)Tffd700}Ta5d6ff] Scan failed (timeout=Tffd700$Te6edf3timeoutTa5d6ff)Ta5d6ff" Tb4b4b4}
Tff7b72null
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8ffconnectTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3connectionJob Tff7b72=
Te6edf3connectionScopeTb4b4b4.Te6edf3launch Tb4b4b4{
Te6edf3reconnectPolicyTb4b4b4.Te6edf3executeTb4b4b4(
Te6edf3attempt Tff7b72= Tb4b4b4{
Tff7b72try Tb4b4b4{
Te6edf3attemptConnectionTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3CancellationExceptionTb4b4b4) Tb4b4b4{
Tff7b72throw Te6edf3e
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3failureTime Tff7b72= Tb4b4b4(Te6edf3nowMillis Tff7b72- Te6edf3connectionStartTimeTb4b4b4)Tb4b4b4.Te6edf3milliseconds
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Failed to connect after Tffd700$Te6edf3failureTimeTa5d6ff" Tb4b4b4}
Te6edf3BleReconnectPolicyTb4b4b4.Te6edf3OutcomeTb4b4b4.Te6edf3FailedTb4b4b4(Te6edf3eTb4b4b4)
Tb4b4b4}
Tb4b4b4}Tb4b4b4,
Te6edf3onTransientDisconnect Tff7b72= Tb4b4b4{ Te6edf3error Tff7b72-Tff7b72>
T8b949e// Guard: if handleFailure already emitted the disconnect callback for this
T8b949e// session (sessionFailed CAS won), don't emit a duplicate from the policy.
T8b949e// Silent recovery: no errorMessage β€” the reconnect loop is still retrying, so
T8b949e// a modal dialog would just confuse the user. The warning log is the
T8b949e// observability surface for this transient event.
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3sessionFailedTb4b4b4.Te6edf3valueTb4b4b4) Tb4b4b4{
Te6edf3errorTff7b72?.Te6edf3let Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Tffa657itTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] BLE reconnect attempt failed; continuing automatic retryTa5d6ff" Tb4b4b4}
Tb4b4b4}
Te6edf3callbackTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanent Tff7b72= Tff7b72falseTb4b4b4)
Tb4b4b4}
Tb4b4b4}Tb4b4b4,
Te6edf3onPermanentDisconnect Tff7b72= Tb4b4b4{ Te6edf3error Tff7b72-Tff7b72>
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3sessionFailedTb4b4b4.Te6edf3valueTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3msg Tff7b72= Te6edf3errorTff7b72?.Te6edf3toDisconnectReasonTb4b4b4(Tb4b4b4)Tff7b72?.Te6edf3second Tff7b72?: Ta5d6ff"Ta5d6ffDevice unreachableTa5d6ff"
Te6edf3callbackTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanent Tff7b72= Tff7b72trueTb4b4b4, Te6edf3errorMessage Tff7b72= Te6edf3msgTb4b4b4)
Tb4b4b4}
Tb4b4b4}Tb4b4b4,
Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Performs a single BLE connect-and-wait cycle.
*
* Finds the device, bonds if needed, connects, discovers services, and waits for disconnect. Returns a
* [BleReconnectPolicy.Outcome] describing how the connection ended.
*/
Tf0883e@SuppressTb4b4b4(Ta5d6ff"Ta5d6ffCyclomaticComplexMethodTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffLongMethodTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffReturnCountTa5d6ff"Tb4b4b4)
Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffattemptConnectionTb4b4b4(Tb4b4b4)Tb4b4b4: Te6edf3BleReconnectPolicyTb4b4b4.Te6edf3Outcome Tb4b4b4{
Te6edf3connectionStartTime Tff7b72= Te6edf3nowMillis
Te6edf3sessionFailedTb4b4b4.Te6edf3value Tff7b72= Tff7b72false
Te6edf3sessionFailureCause Tff7b72= Tff7b72null
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] BLE connection attempt startedTa5d6ff" Tb4b4b4}

Tff7b72val Te6edf3device Tff7b72= Te6edf3findDeviceTb4b4b4(Tb4b4b4)

Te6edf3bondDeviceBeforeConnectTb4b4b4(Te6edf3deviceTb4b4b4)

Tff7b72val Te6edf3state Tff7b72= Te6edf3bleConnectionTb4b4b4.Te6edf3connectAndAwaitTb4b4b4(Te6edf3deviceTb4b4b4, Te6edf3CONNECTION_TIMEOUTTb4b4b4)

Tff7b72if Tb4b4b4(Te6edf3state Tff7b72!is Te6edf3BleConnectionStateTb4b4b4.Te6edf3ConnectedTb4b4b4) Tb4b4b4{
Tff7b72throw Te6edf3RadioNotConnectedExceptionTb4b4b4(Ta5d6ff"Ta5d6ffFailed to connect to device at address Tffd700$Te6edf3addressTa5d6ff"Tb4b4b4)
Tb4b4b4}

T8b949e// Post-OTA GATT cache invalidation: the device rebooted with potentially different
T8b949e// BLE service table handles. Refresh Android's cache and reconnect to force fresh
T8b949e// service discovery before proceeding with profile setup.
Tff7b72val Te6edf3radioServiceForCache Tff7b72= Te6edf3callback Tff7b72as? Te6edf3RadioInterfaceService
Tff7b72if Tb4b4b4(Te6edf3radioServiceForCacheTff7b72?.Te6edf3consumeGattCacheInvalidationRequestTb4b4b4(Tb4b4b4) Tff7b72=Tff7b72= Tff7b72trueTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3invalidated Tff7b72= Te6edf3bleConnectionTb4b4b4.Te6edf3invalidateServiceCacheTb4b4b4(Tb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3d Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700${Te6edf3addressTb4b4b4.Te6edf3anonymizeTb4b4b4(Tb4b4b4)Tffd700}Ta5d6ff] Post-OTA GATT cache invalidation requested: Tffd700$Te6edf3invalidatedTa5d6ff" Tb4b4b4}
Tff7b72if Tb4b4b4(Te6edf3invalidatedTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{
Ta5d6ff"Ta5d6ff[Tffd700${Te6edf3addressTb4b4b4.Te6edf3anonymizeTb4b4b4(Tb4b4b4)Tffd700}Ta5d6ff] Reconnecting after GATT cache refresh to force service rediscoveryTa5d6ff"
Tb4b4b4}
Te6edf3bleConnectionTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3delayTb4b4b4(Te6edf3POST_INVALIDATION_RECONNECT_DELAYTb4b4b4)
Tff7b72val Te6edf3reconnectState Tff7b72= Te6edf3bleConnectionTb4b4b4.Te6edf3connectAndAwaitTb4b4b4(Te6edf3deviceTb4b4b4, Te6edf3CONNECTION_TIMEOUTTb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3reconnectState Tff7b72!is Te6edf3BleConnectionStateTb4b4b4.Te6edf3ConnectedTb4b4b4) Tb4b4b4{
Tff7b72throw Te6edf3RadioNotConnectedExceptionTb4b4b4(
Ta5d6ff"Ta5d6ffFailed to reconnect after post-OTA GATT cache refresh (state=Tffd700$Te6edf3reconnectStateTa5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4}
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700${Te6edf3addressTb4b4b4.Te6edf3anonymizeTb4b4b4(Tb4b4b4)Tffd700}Ta5d6ff] Reconnected after GATT cache refreshTa5d6ff" Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

Tff7b72val Te6edf3gattConnectedAt Tff7b72= Te6edf3nowMillis
Te6edf3isFullyConnected Tff7b72= Tff7b72true
Te6edf3onConnectedTb4b4b4(Tb4b4b4)

Te6edf3discoverServicesAndSetupCharacteristicsTb4b4b4(Tb4b4b4)

T8b949e// If a fatal session failure (fromRadio/logRadio error) forced disconnect during setup,
T8b949e// skip the Connected gate β€” return a retryable failure so BleReconnectPolicy handles it.
Tff7b72if Tb4b4b4(Te6edf3sessionFailureCause Tff7b72!Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3sessionFailureCauseTb4b4b4) Tb4b4b4{
Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Session failed during profile setup β€” returning failed outcomeTa5d6ff"
Tb4b4b4}
Tff7b72return Te6edf3BleReconnectPolicyTb4b4b4.Te6edf3OutcomeTb4b4b4.Te6edf3FailedTb4b4b4(Te6edf3sessionFailureCause Tff7b72?: Te6edf3RuntimeExceptionTb4b4b4(Ta5d6ff"Ta5d6ffSession setup failedTa5d6ff"Tb4b4b4)Tb4b4b4)
Tb4b4b4}

T8b949e// Wait for the StateFlow to actually reflect Connected before watching for the next
T8b949e// Disconnected. connectAndAwait returns synchronously based on the underlying Kable
T8b949e// peripheral state, but our _connectionState observer runs on a separate coroutine and
T8b949e// may lag. Without this gate the next .first { Disconnected } below could match the
T8b949e// *previous* cycle's stale Disconnected value and fire immediately, breaking reconnect.
T8b949e//
T8b949e// RACE GUARD: A fatal fromRadio/logRadio failure can land AFTER the sessionFailureCause
T8b949e// check above but BEFORE connectionState reaches Connected. handleFailure() forces
T8b949e// bleConnection.disconnect(), which should emit Disconnected β€” but if the StateFlow was
T8b949e// already at a stale Disconnected value, it may NOT re-emit (StateFlow suppresses
T8b949e// duplicate values). A bounded timeout ensures we cannot hang: if Connected doesn't
T8b949e// arrive within CONNECTED_GATE_TIMEOUT, we check sessionFailureCause and return a
T8b949e// retryable Failed outcome.
Tff7b72val Te6edf3connectedReached Tff7b72=
Te6edf3withTimeoutOrNullTb4b4b4(Te6edf3CONNECTED_GATE_TIMEOUTTb4b4b4) Tb4b4b4{
Te6edf3bleConnectionTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3first Tb4b4b4{ Tffa657it Tff7b72is Te6edf3BleConnectionStateTb4b4b4.Te6edf3Connected Tb4b4b4}
Tb4b4b4}
Tff7b72if Tb4b4b4(Te6edf3connectedReached Tff7b72=Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3failure Tff7b72= Te6edf3sessionFailureCause Tff7b72?: Te6edf3RuntimeExceptionTb4b4b4(Ta5d6ff"Ta5d6ffTimed out waiting for Connected state gateTa5d6ff"Tb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3failureTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Session failed before Connected gate β€” returning failed outcomeTa5d6ff" Tb4b4b4}
T8b949e// CRITICAL: force GATT cleanup before returning Failed so we don't start a new
T8b949e// attempt over an uncleared session. Without this, a timeout caused by flow lag or
T8b949e// a stale-Disconnected state mismatch would leave a live/half-live GATT handle behind.
Te6edf3radioService Tff7b72= Tff7b72null
Te6edf3isFullyConnected Tff7b72= Tff7b72false
Te6edf3withContextTb4b4b4(Te6edf3NonCancellableTb4b4b4) Tb4b4b4{
Tff7b72try Tb4b4b4{
Te6edf3bleConnectionTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3ignoredTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3ignoredTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] disconnect() failed during Connected-gate timeout cleanupTa5d6ff" Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
Tff7b72return Te6edf3BleReconnectPolicyTb4b4b4.Te6edf3OutcomeTb4b4b4.Te6edf3FailedTb4b4b4(Te6edf3failureTb4b4b4)
Tb4b4b4}

T8b949e// Suspend until the next Disconnected emission. We deliberately do NOT wrap this in a
T8b949e// coroutineScope { launchIn(...); first(...) } pattern: launching a hot StateFlow
T8b949e// collector inside coroutineScope hangs the scope after .first returns (the launched
T8b949e// collector never completes naturally, and coroutineScope waits for all children).
Tff7b72val Te6edf3disconnectedState Tff7b72=
Te6edf3bleConnectionTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3filterIsInstanceTff7b72<Te6edf3BleConnectionStateTb4b4b4.Te6edf3DisconnectedTff7b72>Tb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3disconnectReason Tff7b72= Te6edf3disconnectedStateTb4b4b4.Te6edf3reason
Tff7b72if Tb4b4b4(Te6edf3isFullyConnectedTb4b4b4) Tb4b4b4{
Te6edf3isFullyConnected Tff7b72= Tff7b72false
Te6edf3onDisconnectedTb4b4b4(Tb4b4b4)
Tb4b4b4}

Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] BLE connection dropped (reason: Tffd700$Te6edf3disconnectReasonTa5d6ff), preparing to reconnectTa5d6ff" Tb4b4b4}

T8b949e// Internal session failures (write/read exceptions that triggered handleFailure β†’
T8b949e// disconnect) must NOT be treated as intentional/user disconnects β€” the reconnect policy
T8b949e// needs to escalate backoff for these.
Tff7b72val Te6edf3internalFailure Tff7b72= Te6edf3sessionFailureCause
Tff7b72if Tb4b4b4(Te6edf3internalFailure Tff7b72!Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3internalFailureTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Session forced disconnect due to internal failureTa5d6ff" Tb4b4b4}
Tb4b4b4}
Tff7b72val Te6edf3wasIntentional Tff7b72=
Tff7b72if Tb4b4b4(Te6edf3internalFailure Tff7b72!Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Tff7b72false
Tb4b4b4} Tff7b72else Tb4b4b4{
Te6edf3disconnectReason Tff7b72is Te6edf3DisconnectReasonTb4b4b4.Te6edf3LocalDisconnect
Tb4b4b4}
Tff7b72val Te6edf3connectionUptime Tff7b72= Tb4b4b4(Te6edf3nowMillis Tff7b72- Te6edf3gattConnectedAtTb4b4b4)Tb4b4b4.Te6edf3milliseconds
Tff7b72val Te6edf3wasStable Tff7b72= Te6edf3connectionUptime Tff7b72>Tff7b72= Te6edf3reconnectPolicyTb4b4b4.Te6edf3minStableConnection

Tff7b72if Tb4b4b4(Tff7b72!Te6edf3wasStable Tff7b72&Tff7b72& Tff7b72!Te6edf3wasIntentionalTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3w Tb4b4b4{
Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Connection lasted only Tffd700$Te6edf3connectionUptimeTa5d6ff Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ff(< Tffd700${Te6edf3reconnectPolicyTb4b4b4.Te6edf3minStableConnectionTffd700}Ta5d6ff) β€” treating as unstableTa5d6ff"
Tb4b4b4}
Tb4b4b4}

Tff7b72return Te6edf3BleReconnectPolicyTb4b4b4.Te6edf3OutcomeTb4b4b4.Te6edf3DisconnectedTb4b4b4(Te6edf3wasStable Tff7b72= Te6edf3wasStableTb4b4b4, Te6edf3wasIntentional Tff7b72= Te6edf3wasIntentionalTb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffbondDeviceBeforeConnectTb4b4b4(Te6edf3deviceTb4b4b4: Te6edf3BleDeviceTb4b4b4) Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3isBondedTb4b4b4(Te6edf3addressTb4b4b4)Tb4b4b4) Tff7b72return

T8b949e// Bond before connecting: firmware may require an encrypted link, and without a bond Android fails with
T8b949e// status 5 or 133. Non-Android targets use repository-specific no-op behavior.
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Device not bonded, initiating bondingTa5d6ff" Tb4b4b4}
Tff7b72try Tb4b4b4{
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Bonding successfulTa5d6ff" Tb4b4b4}
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3CancellationExceptionTb4b4b4) Tb4b4b4{
Tff7b72throw Te6edf3e
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
T8b949e// bond() failed. Android may still have recorded the bond, in which case we can safely continue into GATT
T8b949e// setup. If the device is still not bonded, continuing would fail later with a cryptic status (5/133), so
T8b949e// stop now and let BleReconnectPolicy own the retry/backoff.
Tff7b72if Tb4b4b4(Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3isBondedTb4b4b4(Te6edf3addressTb4b4b4)Tb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Bonding reported failure but device is bonded; continuingTa5d6ff" Tb4b4b4}
Tb4b4b4} Tff7b72else Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Bonding failed and device is still not bonded; stopping connection attemptTa5d6ff" Tb4b4b4}
Tff7b72throw Te6edf3RadioNotConnectedExceptionTb4b4b4(Ta5d6ff"Ta5d6ffBonding failed and device is still not bondedTa5d6ff"Tb4b4b4, Te6edf3eTb4b4b4)
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffonConnectedTb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72try Tb4b4b4{
Te6edf3bleConnectionTb4b4b4.Te6edf3deviceFlowTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tff7b72?.Te6edf3let Tb4b4b4{ Te6edf3device Tff7b72-Tff7b72>
Tff7b72val Te6edf3rssi Tff7b72= Te6edf3retryBleOperationTb4b4b4(Te6edf3tag Tff7b72= Te6edf3addressTb4b4b4) Tb4b4b4{ Te6edf3deviceTb4b4b4.Te6edf3readRssiTb4b4b4(Tb4b4b4) Tb4b4b4}
Te6edf3LoggerTb4b4b4.Te6edf3d Tb4b4b4{
Ta5d6ff"Ta5d6ff[Tffd700${Te6edf3addressTb4b4b4.Te6edf3anonymizeTb4b4b4(Tb4b4b4)Tffd700}Ta5d6ff] Connection confirmed. Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ffInitial RSSI: Tffd700${Te6edf3rssiTff7b72?.Te6edf3let Tb4b4b4{ Ta5d6ff"Tffd700$Te6edf3itTa5d6ff dBmTa5d6ff" Tb4b4b4} Tff7b72?: Ta5d6ff"Te6edf3unknownTa5d6ff"Tffd700}Ta5d6ff"
Tb4b4b4}
Tb4b4b4}
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3CancellationExceptionTb4b4b4) Tb4b4b4{
Tff7b72throw Te6edf3e
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Failed to read initial connection RSSITa5d6ff" Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8ffonDisconnectedTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3radioService Tff7b72= Tff7b72null
T8b949e// Atomic first-writer-wins: if handleFailure already claimed this session's failure
T8b949e// callback (or another onDisconnected raced), CAS returns false and we skip the
T8b949e// duplicate. The forced disconnect() from handleFailure causes Kable to emit
T8b949e// Disconnected, which routes here β€” without this guard the UI would see two
T8b949e// onDisconnect calls (one with the real error message from handleFailure, one
T8b949e// generic from here).
Tff7b72val Te6edf3firstWriter Tff7b72= Te6edf3sessionFailedTb4b4b4.Te6edf3compareAndSetTb4b4b4(Te6edf3expect Tff7b72= Tff7b72falseTb4b4b4, Te6edf3update Tff7b72= Tff7b72trueTb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] BLE disconnected - Tffd700${Te6edf3formatSessionStatsTb4b4b4(Tb4b4b4)Tffd700}Ta5d6ff" Tb4b4b4}
Tff7b72if Tb4b4b4(Te6edf3firstWriterTb4b4b4) Tb4b4b4{
T8b949e// Signal immediately so the UI reflects the disconnect while reconnect continues.
Te6edf3callbackTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanent Tff7b72= Tff7b72falseTb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@SuppressTb4b4b4(Ta5d6ff"Ta5d6ffLongMethodTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffThrowsCountTa5d6ff"Tb4b4b4)
Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffdiscoverServicesAndSetupCharacteristicsTb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72try Tb4b4b4{
Te6edf3bleConnectionTb4b4b4.Te6edf3profileTb4b4b4(Te6edf3serviceUuid Tff7b72= Te6edf3SERVICE_UUIDTb4b4b4) Tb4b4b4{ Te6edf3service Tff7b72-Tff7b72>
Tff7b72val Te6edf3radioService Tff7b72= Te6edf3serviceTb4b4b4.Te6edf3toMeshtasticRadioProfileTb4b4b4(Tb4b4b4)

Te6edf3radioServiceTb4b4b4.Te6edf3fromRadio
Tb4b4b4.Te6edf3onEach Tb4b4b4{ Te6edf3packet Tff7b72-Tff7b72>
Te6edf3LoggerTb4b4b4.Te6edf3v Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Received packet fromRadio (Tffd700${Te6edf3packetTb4b4b4.Te6edf3sizeTffd700}Ta5d6ff bytes)Ta5d6ff" Tb4b4b4}
Te6edf3dispatchPacketTb4b4b4(Te6edf3packetTb4b4b4)
Tb4b4b4}
Tb4b4b4.Te6edf3catch Tb4b4b4{ Te6edf3e Tff7b72-Tff7b72>
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Error in fromRadio flowTa5d6ff" Tb4b4b4}
Te6edf3handleFailureTb4b4b4(Te6edf3eTb4b4b4)
Tb4b4b4}
Tb4b4b4.Te6edf3launchInTb4b4b4(Tff7b72thisTb4b4b4)

Te6edf3radioServiceTb4b4b4.Te6edf3logRadio
Tb4b4b4.Te6edf3onEach Tb4b4b4{ Te6edf3packet Tff7b72-Tff7b72>
Te6edf3LoggerTb4b4b4.Te6edf3v Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Received packet logRadio (Tffd700${Te6edf3packetTb4b4b4.Te6edf3sizeTffd700}Ta5d6ff bytes)Ta5d6ff" Tb4b4b4}
Te6edf3dispatchPacketTb4b4b4(Te6edf3packetTb4b4b4)
Tb4b4b4}
Tb4b4b4.Te6edf3catch Tb4b4b4{ Te6edf3e Tff7b72-Tff7b72>
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Error in logRadio flowTa5d6ff" Tb4b4b4}
Te6edf3handleFailureTb4b4b4(Te6edf3eTb4b4b4)
Tb4b4b4}
Tb4b4b4.Te6edf3launchInTb4b4b4(Tff7b72thisTb4b4b4)

Tff7b72thisTf0883e@BleRadioTransport.radioService Tff7b72= Te6edf3radioService

Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Profile service active and characteristics subscribedTa5d6ff" Tb4b4b4}

T8b949e// Wait for FROMNUM CCCD write before triggering the Meshtastic handshake.
T8b949e// Bounded: if fromRadio fails before subscriptionReady completes, handleFailure
T8b949e// sets sessionFailureCause. The timeout also prevents a hang if FROMNUM observe
T8b949e// never completes for reasons other than a fatal exception (e.g., firmware doesn't
T8b949e// send CCCD confirmation). We MUST abort setup on timeout β€” proceeding without
T8b949e// subscription readiness creates a half-initialized session.
Tff7b72val Te6edf3subscriptionReady Tff7b72=
Te6edf3withTimeoutOrNullTb4b4b4(Te6edf3SUBSCRIPTION_READY_TIMEOUTTb4b4b4) Tb4b4b4{
Te6edf3radioServiceTb4b4b4.Te6edf3awaitSubscriptionReadyTb4b4b4(Tb4b4b4)
Tff7b72true
Tb4b4b4} Tff7b72?: Tff7b72false
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3subscriptionReady Tff7b72|Tff7b72| Te6edf3sessionFailedTb4b4b4.Te6edf3valueTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3cause Tff7b72=
Te6edf3sessionFailureCause Tff7b72?: Te6edf3RuntimeExceptionTb4b4b4(Ta5d6ff"Ta5d6ffTimed out waiting for FROMNUM subscription readinessTa5d6ff"Tb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3causeTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3reason Tff7b72= Tff7b72if Tb4b4b4(Tff7b72!Te6edf3subscriptionReadyTb4b4b4) Ta5d6ff"Ta5d6fftimed outTa5d6ff" Tff7b72else Ta5d6ff"Ta5d6fffailedTa5d6ff"
Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Subscription wait Tffd700$Te6edf3reasonTa5d6ff β€” aborting setupTa5d6ff"
Tb4b4b4}
Tff7b72throw Te6edf3cause
Tb4b4b4}

T8b949e// Log negotiated MTU for diagnostics
Tff7b72val Te6edf3maxLen Tff7b72= Te6edf3bleConnectionTb4b4b4.Te6edf3maximumWriteValueLengthTb4b4b4(Te6edf3BleWriteTypeTb4b4b4.Te6edf3WITHOUT_RESPONSETb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): Tffd700$Te6edf3maxLenTa5d6ff bytesTa5d6ff" Tb4b4b4}

Te6edf3requestHighPriorityAndScheduleDowngradeTb4b4b4(Tb4b4b4)

T8b949e// Guard: if handleFailure fired during setup (e.g., fromRadio/logRadio fatal
T8b949e// exception after subscriptionReady completed but before this line), do NOT call
T8b949e// onConnect β€” it would set Connected state on a dead session. handleFailure has
T8b949e// already emitted the disconnect callback.
T8b949e//
T8b949e// ORDERING NOTE: callback.onConnect() is emitted here, BEFORE attemptConnection()
T8b949e// re-confirms the link via the Connected gate (see CONNECTED_GATE_TIMEOUT). In the
T8b949e// rare case the gate times out (connectionState observer-coroutine lag, or a stale
T8b949e// Disconnected value the StateFlow does not re-emit), the UI has briefly seen
T8b949e// Connected. This is deliberate and acceptable:
T8b949e// - The gate-timeout path returns Outcome.Failed, which drives BleReconnectPolicy
T8b949e// (Retry backoff immediately; onTransientDisconnect β†’ DeviceSleep once
T8b949e// consecutiveFailures reaches failureThreshold, default 3).
T8b949e// - The timeout path nulls radioService and forces bleConnection.disconnect()
T8b949e// under NonCancellable, so handleSendToRadio() fails fast against a null
T8b949e// service and the next attempt starts over a clean GATT handle.
T8b949e// The net worst case is a brief Connected indication while the transport cycles a
T8b949e// sub-threshold retry β€” a cosmetic UX lag, not a correctness or data issue.
T8b949e// Deferring onConnect until after the gate would require a structural refactor of
T8b949e// the profile-setup callback and introduce its own races, so the current ordering
T8b949e// is retained.
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3sessionFailedTb4b4b4.Te6edf3valueTb4b4b4) Tb4b4b4{
Tff7b72thisTf0883e@BleRadioTransport.callback.onConnectTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72else Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3w Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Session failed during setup β€” skipping onConnectTa5d6ff" Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3CancellationExceptionTb4b4b4) Tb4b4b4{
T8b949e// Scope was cancelled externally β€” still ensure GATT cleanup runs so we don't
T8b949e// leak a BluetoothGatt handle and trigger GATT status 133 on the next attempt.
Te6edf3radioService Tff7b72= Tff7b72null
Te6edf3isFullyConnected Tff7b72= Tff7b72false
Te6edf3withContextTb4b4b4(Te6edf3NonCancellableTb4b4b4) Tb4b4b4{
Tff7b72try Tb4b4b4{
Te6edf3bleConnectionTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3ignoredTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3ignoredTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] disconnect() failed during cancellation cleanupTa5d6ff" Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
Tff7b72throw Te6edf3e
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Profile service discovery or operation failedTa5d6ff" Tb4b4b4}
T8b949e// Clear stale state so the next attempt starts clean β€” if failure happened after
T8b949e// radioService assignment but before callback.onConnect(), stale state would survive.
Te6edf3radioService Tff7b72= Tff7b72null
Te6edf3isFullyConnected Tff7b72= Tff7b72false
Te6edf3withContextTb4b4b4(Te6edf3NonCancellableTb4b4b4) Tb4b4b4{
Tff7b72try Tb4b4b4{
Te6edf3bleConnectionTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3ignoredTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3ignoredTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] disconnect() failed after profile errorTa5d6ff" Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
Tff7b72throw Te6edf3e T8b949e// Re-throw so attemptConnection() returns Outcome.Failed(e) for policy backoff.
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Requests high BLE connection priority for the initial config burst, then schedules a downgrade to balanced
* priority after [PRIORITY_DOWNGRADE_DELAY] to conserve battery.
*/
Tff7b72private Tff7b72suspend Tff7b72fun Te6edf3CoroutineScopeTb4b4b4.Td2a8ffrequestHighPriorityAndScheduleDowngradeTb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3bleConnectionTb4b4b4.Te6edf3requestHighConnectionPriorityTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3d Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Requested high BLE connection priorityTa5d6ff" Tb4b4b4}
T8b949e// Wait for the connection parameter update before starting heavy traffic.
Te6edf3delayTb4b4b4(T79c0ff1.Te6edf3secondsTb4b4b4)
Tb4b4b4}
Te6edf3launch Tb4b4b4{
Te6edf3delayTb4b4b4(Te6edf3PRIORITY_DOWNGRADE_DELAYTb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3bleConnectionTb4b4b4.Te6edf3requestBalancedConnectionPriorityTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3d Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Downgraded to balanced BLE connection priorityTa5d6ff" Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

Tf0883e@Volatile Tff7b72private Tff7b72var Te6edf3radioServiceTb4b4b4: Te6edf3MeshtasticRadioProfile? Tff7b72= Tff7b72null

T8b949e// --- RadioTransport Implementation ---

T8b949e/**
* Sends a packet to the radio with retry support.
*
* Write-failure policy: any non-cancellation exception from a failed write (after exhausting [retryBleOperation]
* retries) is treated as fatal to the current BLE session. Production logs show long-running sessions eventually
* failing writes with [NotConnectedException] after hundreds of successful writes β€” by that point the GATT handle
* is stale and retrying in-place cannot recover. Calling [handleFailure] forces a full GATT teardown + reconnect
* cycle, which is the only reliable recovery for a dead session. Transient single-write blips are absorbed by
* [retryBleOperation]'s 3-attempt retry before reaching this catch.
*
* @param p The packet to send.
*/
Tff7b72override Tff7b72fun Td2a8ffhandleSendToRadioTb4b4b4(Te6edf3pTb4b4b4: Te6edf3ByteArrayTb4b4b4) Tb4b4b4{
T8b949e// Fast-path check: skip coroutine launch entirely if no transport is active.
Tff7b72if Tb4b4b4(Te6edf3radioService Tff7b72=Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3w Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] toRadio characteristic unavailable, can't send dataTa5d6ff" Tb4b4b4}
Tff7b72return
Tb4b4b4}
Te6edf3connectionScopeTb4b4b4.Te6edf3launch Tb4b4b4{
Te6edf3writeMutexTb4b4b4.Te6edf3withLock Tb4b4b4{
T8b949e// Re-read radioService UNDER the lock β€” handleFailure may have nulled it
T8b949e// between the outer check and lock acquisition. Without this, a queued send
T8b949e// can retry writes against a stale/dead profile.
Tff7b72val Te6edf3currentService Tff7b72=
Te6edf3radioService
Tff7b72?: Te6edf3run Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3w Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] toRadio characteristic cleared during write queueTa5d6ff" Tb4b4b4}
Tff7b72returnTf0883e@withLock
Tb4b4b4}
Tff7b72try Tb4b4b4{
Te6edf3retryBleOperationTb4b4b4(Te6edf3tag Tff7b72= Te6edf3addressTb4b4b4, Te6edf3retryWhile Tff7b72= Tb4b4b4{ Te6edf3currentService Tff7b72=Tff7b72=Tff7b72= Te6edf3radioService Tb4b4b4}Tb4b4b4) Tb4b4b4{
Te6edf3currentServiceTb4b4b4.Te6edf3sendToRadioTb4b4b4(Te6edf3pTb4b4b4)
Tb4b4b4}
Tff7b72val Te6edf3sent Tff7b72= Te6edf3packetsSentTb4b4b4.Te6edf3incrementAndGetTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3txBytes Tff7b72= Te6edf3bytesSentTb4b4b4.Te6edf3addAndGetTb4b4b4(Te6edf3pTb4b4b4.Te6edf3sizeTb4b4b4.Te6edf3toLongTb4b4b4(Tb4b4b4)Tb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3v Tb4b4b4{
Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Wrote packet #Tffd700$Te6edf3sentTa5d6ff Ta5d6ff" Tff7b72+ Ta5d6ff"Ta5d6ffto toRadio (Tffd700${Te6edf3pTb4b4b4.Te6edf3sizeTffd700}Ta5d6ff bytes, total TX: Tffd700$Te6edf3txBytesTa5d6ff bytes)Ta5d6ff"
Tb4b4b4}
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3CancellationExceptionTb4b4b4) Tb4b4b4{
Tff7b72throw Te6edf3e
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
T8b949e// Guard: only call handleFailure if this write was against the CURRENT session.
T8b949e// If radioService was replaced (new reconnect cycle) or cleared (handleFailure
T8b949e// already ran), this is a stale write from the old session β€” silently discard.
Tff7b72if Tb4b4b4(Te6edf3currentService Tff7b72=Tff7b72=Tff7b72= Te6edf3radioServiceTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{
Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Failed to write packet to toRadioCharacteristic after Ta5d6ff" Tff7b72+
Ta5d6ff"Tffd700${Te6edf3packetsSentTb4b4b4.Te6edf3valueTffd700}Ta5d6ff successful writesTa5d6ff"
Tb4b4b4}
Te6edf3handleFailureTb4b4b4(Te6edf3eTb4b4b4)
Tb4b4b4} Tff7b72else Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3dTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Stale write failure ignored because the session was replacedTa5d6ff" Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

Tff7b72override Tff7b72fun Td2a8ffkeepAliveTb4b4b4(Tb4b4b4) Tb4b4b4{
T8b949e// Delegate to HeartbeatSender which sends a ToRadio heartbeat with a unique nonce
T8b949e// so the firmware resets its power-saving idle timer. After sending, it schedules
T8b949e// a delayed re-drain to pick up the queueStatus response.
Te6edf3connectionScopeTb4b4b4.Te6edf3launch Tb4b4b4{ Te6edf3heartbeatSenderTb4b4b4.Te6edf3sendHeartbeatTb4b4b4(Tb4b4b4) Tb4b4b4}
Tb4b4b4}

T8b949e/** Closes the connection to the device. */
Tff7b72override Tff7b72suspend Tff7b72fun Td2a8ffcloseTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Disconnecting. Tffd700${Te6edf3formatSessionStatsTb4b4b4(Tb4b4b4)Tffd700}Ta5d6ff" Tb4b4b4}
Te6edf3connectionScopeTb4b4b4.Te6edf3cancelTb4b4b4(Tb4b4b4)
T8b949e// GATT cleanup must run under NonCancellable so a cancelled caller cannot skip it,
T8b949e// which would leak BluetoothGatt and trigger status 133 on the next reconnect.
T8b949e// Using withContext (not runBlocking) keeps the caller's thread free β€” this is
T8b949e// critical when close() is invoked from the main thread during process shutdown.
Te6edf3withContextTb4b4b4(Te6edf3NonCancellableTb4b4b4) Tb4b4b4{
Tff7b72try Tb4b4b4{
Te6edf3withTimeoutOrNullTb4b4b4(Te6edf3GATT_CLEANUP_TIMEOUTTb4b4b4) Tb4b4b4{ Te6edf3bleConnectionTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4) Tb4b4b4}
Tb4b4b4} Tff7b72catch Tb4b4b4(Tf0883e@SuppressTb4b4b4(Ta5d6ff"Ta5d6ffTooGenericExceptionCaughtTa5d6ff"Tb4b4b4) Te6edf3eTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Failed to disconnect in close()Ta5d6ff" Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
T8b949e// Our own disconnect succeeded β€” the exception-handler safety net is no longer
T8b949e// needed. Cancel the detached cleanup scope so it doesn't outlive us in tests
T8b949e// or process lifetime.
Te6edf3cleanupScopeTb4b4b4.Te6edf3cancelTb4b4b4(Tb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8ffdispatchPacketTb4b4b4(Te6edf3packetTb4b4b4: Te6edf3ByteArrayTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3received Tff7b72= Te6edf3packetsReceivedTb4b4b4.Te6edf3incrementAndGetTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3rxBytes Tff7b72= Te6edf3bytesReceivedTb4b4b4.Te6edf3addAndGetTb4b4b4(Te6edf3packetTb4b4b4.Te6edf3sizeTb4b4b4.Te6edf3toLongTb4b4b4(Tb4b4b4)Tb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3v Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Dispatching packet #Tffd700$Te6edf3receivedTa5d6ff Ta5d6ff" Tff7b72+ Ta5d6ff"Ta5d6ff(Tffd700${Te6edf3packetTb4b4b4.Te6edf3sizeTffd700}Ta5d6ff bytes, total RX: Tffd700$Te6edf3rxBytesTa5d6ff bytes)Ta5d6ff" Tb4b4b4}
Te6edf3callbackTb4b4b4.Te6edf3handleFromRadioTb4b4b4(Te6edf3packetTb4b4b4)
Tb4b4b4}

T8b949e/**
* Preserves the first session-failure cause across concurrent failures. Called before the [sessionFailed] CAS in
* [handleFailure] and [exceptionHandler] to eliminate the window where [sessionFailed] is true but
* [sessionFailureCause] is still null.
*/
Tff7b72private Tff7b72fun Td2a8ffrecordSessionFailureCauseTb4b4b4(Te6edf3throwableTb4b4b4: Te6edf3ThrowableTb4b4b4) Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3sessionFailureCause Tff7b72=Tff7b72= Tff7b72nullTb4b4b4) Te6edf3sessionFailureCause Tff7b72= Te6edf3throwable
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8ffhandleFailureTb4b4b4(Te6edf3throwableTb4b4b4: Te6edf3ThrowableTb4b4b4) Tb4b4b4{
T8b949e// CancellationException signals intentional scope cancellation (close() called).
T8b949e// Never surface it as a user-facing disconnect error.
Tff7b72if Tb4b4b4(Te6edf3throwable Tff7b72is Te6edf3CancellationExceptionTb4b4b4) Tff7b72return

T8b949e// Record the cause BEFORE the CAS so there is no window where another coroutine
T8b949e// sees sessionFailed == true but sessionFailureCause == null. first-cause is
T8b949e// preserved: a concurrent loser only overwrites if still null.
Te6edf3recordSessionFailureCauseTb4b4b4(Te6edf3throwableTb4b4b4)

T8b949e// Deduplicate via atomic CAS: only the first failure per connection attempt triggers
T8b949e// session teardown. Heartbeat writes that arrive after the first failure must not spam
T8b949e// callbacks. compareAndSet(false, true) returns true iff THIS caller is the first.
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3sessionFailedTb4b4b4.Te6edf3compareAndSetTb4b4b4(Te6edf3expect Tff7b72= Tff7b72falseTb4b4b4, Te6edf3update Tff7b72= Tff7b72trueTb4b4b4)Tb4b4b4) Tff7b72return

T8b949e// Tear down stale session state immediately so future writes fail-fast without retrying
T8b949e// against a dead GATT handle.
Te6edf3radioService Tff7b72= Tff7b72null
Te6edf3isFullyConnected Tff7b72= Tff7b72false

Tff7b72val Tb4b4b4(Te6edf3isPermanentTb4b4b4, Te6edf3msgTb4b4b4) Tff7b72= Te6edf3throwableTb4b4b4.Te6edf3toDisconnectReasonTb4b4b4(Tb4b4b4)
T8b949e// Silent recovery for non-permanent failures: the transport tears down stale GATT state
T8b949e// and reconnects automatically, so surfacing a modal for a transient session failure is
T8b949e// confusing UX. Permanent failures (pairing, missing characteristic, etc.) remain
T8b949e// user-facing.
Te6edf3callbackTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanentTb4b4b4, Te6edf3errorMessage Tff7b72= Tff7b72if Tb4b4b4(Te6edf3isPermanentTb4b4b4) Te6edf3msg Tff7b72else Tff7b72nullTb4b4b4)

Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3throwableTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Session failure β€” forcing cleanup for reconnectTa5d6ff" Tb4b4b4}

T8b949e// Force GATT disconnect on the detached cleanupScope (matching the pattern used by
T8b949e// the exceptionHandler defined above). This causes Kable's connectionState
T8b949e// to emit Disconnected, unblocking attemptConnection so BleReconnectPolicy iterates.
Te6edf3cleanupScopeTb4b4b4.Te6edf3launch Tb4b4b4{
Tff7b72try Tb4b4b4{
Te6edf3bleConnectionTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3wTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ff[Tffd700$Te6edf3addressTa5d6ff] Failed to disconnect after session failureTa5d6ff" Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

T8b949e/** Formats a one-line session statistics summary for logging. */
Tff7b72private Tff7b72fun Td2a8ffformatSessionStatsTb4b4b4(Tb4b4b4)Tb4b4b4: Tffa657String Tb4b4b4{
Tff7b72val Te6edf3uptime Tff7b72= Tff7b72if Tb4b4b4(Te6edf3connectionStartTime Tff7b72> T79c0ff0Tb4b4b4) Te6edf3nowMillis Tff7b72- Te6edf3connectionStartTime Tff7b72else T79c0ff0
Tff7b72return Ta5d6ff"Ta5d6ffUptime: Tffd700${Te6edf3uptimeTffd700}Ta5d6ffms, Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ffPackets RX: Tffd700${Te6edf3packetsReceivedTb4b4b4.Te6edf3valueTffd700}Ta5d6ff (Tffd700${Te6edf3bytesReceivedTb4b4b4.Te6edf3valueTffd700}Ta5d6ff bytes), Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ffPackets TX: Tffd700${Te6edf3packetsSentTb4b4b4.Te6edf3valueTffd700}Ta5d6ff (Tffd700${Te6edf3bytesSentTb4b4b4.Te6edf3valueTffd700}Ta5d6ff bytes)Ta5d6ff"
Tb4b4b4}

Tff7b72private Tff7b72fun Te6edf3ThrowableTb4b4b4.Td2a8fftoDisconnectReasonTb4b4b4(Tb4b4b4)Tb4b4b4: Te6edf3PairTff7b72<Tffa657BooleanTb4b4b4, Tffa657StringTff7b72> Tb4b4b4{
Te6edf3classifyBleExceptionTb4b4b4(Tb4b4b4)Tff7b72?.Te6edf3let Tb4b4b4{
Tff7b72return Tffa657itTb4b4b4.Te6edf3isPermanent Te6edf3to Tffa657itTb4b4b4.Te6edf3message
Tb4b4b4}

Tff7b72val Te6edf3msg Tff7b72=
Tff7b72when Tb4b4b4(Tff7b72thisTb4b4b4) Tb4b4b4{
Tff7b72is Te6edf3RadioNotConnectedException Tff7b72-Tff7b72> Tff7b72thisTb4b4b4.Te6edf3message Tff7b72?: Ta5d6ff"Ta5d6ffDevice not foundTa5d6ff"

Tff7b72is Te6edf3NoSuchElementExceptionTb4b4b4,
Tff7b72is Te6edf3IllegalArgumentExceptionTb4b4b4,
Tff7b72-Tff7b72> Ta5d6ff"Ta5d6ffRequired characteristic missingTa5d6ff"

Tff7b72else Tff7b72-Tff7b72> Tff7b72thisTb4b4b4.Te6edf3message Tff7b72?: Tff7b72thisTff7b72::Te6edf3classTb4b4b4.Te6edf3simpleName Tff7b72?: Ta5d6ff"Ta5d6ffUnknownTa5d6ff"
Tb4b4b4}
Tff7b72return Tff7b72false Te6edf3to Te6edf3msg
Tb4b4b4}
Tb4b4b4}

Served by rngit 1.5.0 - Generated in 0.15s